Pinvon's Blog

所见, 所闻, 所思, 所想

JavaScript笔记--异步

异步操作概述

单线程模型

JavaScript 在同一时刻只能执行一个任务, 其他任务都要排除等待. 但 JavaScript 引擎支持多线程, 只是单个脚本只能在一个称为主线程的线程上运行, 其他线程在后台运行.

既然 JavaScript 支持多线程, 为什么还要采用单线程? 因为 JavaScript 认为, 对于网页脚本语言来说, 多线程太过复杂, 要考虑到资源共享, 这就需要互斥锁等, 所以, JavaScript 的核心就是单线程(为了充分利用多核, 现在允许 JavaScript 脚本创建多线程, 但子线程完全受主线程的控制).

所以, 使用单线程的好处就是为了简单, 但缺点也很明显, 如果使用到了 Ajax 等与请求相关的操作, 就有可能因为网络问题而一直卡在某界面, 或者不小心写了个死循环等, 都会导致这个问题, 使得后面的任务一直处于等待状态.

同步任务和异步任务

同步任务: 未被 JavaScript 引擎挂起的任务, 在主线程上排队执行.

异步任务: 被 JavaScript 引擎挂起的任务(即不在主线程), 当 JavaScript 引擎认为某个异步任务可以执行(如, Ajax 操作从服务器得到了结果)了, 这个异步任务就通过回调函数, 进入到主线程执行. 而当时在异步任务后面的代码, 在调用回调函数之前就已经执行. 所以, 异步任务不具有"堵塞"效应.

例子: Ajax 操作既可以当成是同步任务处理, 也可以当成异步任务处理. 如果是同步任务, 主线程要等待 Ajax 操作返回再继续执行; 如果是异步任务, 主线程会在发出 Ajax 请求后继续往下执行而不等待, 当 Ajax 有了返回值后, 主线程再执行对应的回调函数.

任务队列和事件循环

任务队列: JavaScript 引擎除了提供主线程之外, 还提供了多个任务队列. 在主线程排除的任务是同步任务, 在任务队列中排除的是异步任务. 当主线程执行完同步任务后, 就去检查任务队列中是否有任务满足某条件, 若满足, 则该异步任务重新进入主线程开始执行.

事件循环: 当 JavaScript 引擎执行完同步任务后, 就会检查一遍是否有异步任务满足条件了. 这种循环检查的机制, 就叫事件循环.

异步操作的模式

回调函数

function f1() { ... }
function f2() { ... }
f1();
f2();

我们写出这种程序时, 一般是希望 f1() 执行完后, 再执行 f2().

但是, 如果 f1() 是异步操作, 则 f2() 不会等待 f1() 结束, 而是立即执行.

我们可以通过回调函数的写法, 来使 f2() 等待 f1() 结束:

function f1(callback) { ...; callback(); }
function f2() { ... }
f1(f2);

回调函数的优点是简单, 容易理解和实现. 缺点是各部分之间耦合度高, 使程序结构混乱(当多个回调函数嵌套时), 而且每个任务只能指定一个回调函数.

事件监听

由于异步任务的执行不取决于代码的顺序, 而取决于某个事件是否发生, 所以就有了事件监听的思路.

采用 jQuery 有写法, 为 f1() 绑定一个事件:

f1.on('done', f2);

当 f1() 发生 done 事件, 就执行 f2.

考虑如下代码:

function f1() {
    setTimeout(function () {
        ...
        f1.trigger('done');
    }, 1000);
}

f1.trigger('done') 表示执行完成后, 立即触发 done 事件.

事件监听的优点是容易理解, 可以绑定多个事件, 为每个事件指定多个回调函数, 可以去耦合, 有利于模块化. 缺点是整个程序变成事件驱动型, 运行流程不清晰.

发布/订阅

把事件理解成信息, 假设有一个信号中心, 当某任务执行完成时, 就向信号中心发布信息, 其他任务可以向信号中心订阅该信号, 从而知道什么时候自己可以开始执行.

f2 向信号中心 jQuery 订阅 done 信号:

jQuery.subscript('done', f2);
function f1() {
    setTimeout(function() {
        ...
        jQuery.publish('done');
    }, 1000);
}

当 f1() 执行完成后, 向信号中心 jQuery 发布 done 信号, 从而引发 f2() 的执行. f2() 执行以后, 可以取消订阅:

jQuery.unsubscribe('done', f2);

异步操作的流程控制

如果有多个异步操作, 如何确定异步操作的执行顺序?

假设有如下异步任务, 每次执行耗时 1s, 然后调用回调函数:

function f1(arg, callback) {
    setTimeout(function () { callback(arg*2); }, 1000);
}

如果有 6 个这样的异步任务, 全部完成后才能执行 final(), 应如何写?

function final(value) { ... }
f1(1, function(value) {
    f1(value, function(value) {
        f1(value, function(value) {
            f1(value, function(value) {
                f1(value, function(value) {
                    f1(value, final);
                });
            });
        });
    });
});

显然, 这种嵌套方式非常麻烦. 可以使用如下方式解决:

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;

function async(arg, callback) {
  console.log('参数为 ' + arg +' , 1秒后返回结果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
  console.log('完成: ', value);
}

function launcher() {
  while(running < limit && items.length > 0) {
    var item = items.shift();
    async(item, function(result) {
      results.push(result);
      running--;
      if(items.length > 0) {
        launcher();
      } else if(running == 0) {
        final(results);
      }
    });
    running++;
  }
}

launcher();

使用 limit 来限制每次最多能并行执行的任务数, 以免过分占用系统资源. running 记录当前正在运行的任务数, 如果低于 limit, 就再启动一个新的任务. 当所有任务都执行完了的时候, 就执行 final()

Promise

Promise 是 JavaScript 中异步编程的一种解决方案, 比传统的解决方案(回调函数, 事件)更加强大.

Promise 是一个对象, 我们可以把它当成一个容器, 里面存放着异步操作完成后需要做的事.

Promise 对象代表一个异步操作, 它有三种状态: pending(进行中), fulfilled(已成功), rejected(已失败). 状态的转移只存在两种可能: pending->fulfilled, pending->rejected. 外界无法对状态进行更改, 当状态转移完成后, 我们称之为 resolved(定型).

有了 Promise, 我们可以以同步的方式表达异步, 避免了层层嵌套的回调函数.

创建 Project 实例

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

Promise 构造函数接收一个函数作为参数, 该函数有 resolve 和 reject 两个参数, 它们都是由 JavaScript 引擎提供的.

resolve(): 当异步操作成功时, 将 Promise 对象的状态改变, 并将异步操作的结果返回.

reject(): 当异步操作失败时, 将 Promise 对象的状态改变, 并将报错信息返回.

then()

生成 Promise 实例后, 可以使用 then() 指定 resolved 状态和 rejected 状态的回调函数.

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});

可以看到, then() 可以接受两个函数作为参数, 这两个函数在状态变为 resolved 时和 rejected 时分别调用.

例子

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms, 'done');
  });
}

timeout(100).then((value) => {
  console.log(value);
});

timeout() 返回一个 Promise 对象, 表示一段时间以后才会发生的结果. 过了指定的时间以后, Promise 对象的状态变化 resolved, 就会触发 then() 绑定的回调函数.

let promise = new Promise(function(resolve, reject) {
  console.log('Promise');
  resolve();
});

promise.then(function() {
  console.log('resolved.');
});

console.log('Hi!');

// Promise
// Hi!
// resolved

Promise 新建后会立即执行, 输出 Promise; 然后, then() 方法指定的回调函数, 将在当前脚本所有同步任务执行完后才会执行, 所以, resolved 最后输出.

Comments

使用 Disqus 评论
comments powered by Disqus